import * as _ from "lodash-es"
import * as R from "remeda"
import {
    EntityName,
    GRAPHER_TAB_QUERY_PARAMS,
    GrapherQueryParams,
    GrapherTabName,
    GrapherTabQueryParam,
    OwidGdocType,
    TagGraphRoot,
    TimeBounds,
    SearchIndexName,
    Filter,
    FilterType,
    ScoredFilter,
    SearchResultType,
    SearchTopicType,
    SearchFacetFilters,
    ChartRecordType,
    SearchChartHit,
    IChartHit,
    SearchUrlParam,
    SynonymMap,
    Ngram,
    WordPositioned,
    ScoredFilterPositioned,
} from "@ourworldindata/types"
import {
    Url,
    countriesByName,
    FuzzySearch,
    FuzzySearchResult,
    getAllChildrenOfArea,
    timeBoundToTimeBoundString,
    queryParamsToStr,
    omitUndefinedValues,
} from "@ourworldindata/utils"
import {
    generateSelectedEntityNamesParam,
    isValidTabQueryParam,
    mapGrapherTabNameToQueryParam,
} from "@ourworldindata/grapher"
import { getIndexName } from "./searchClient.js"
import {
    faBook,
    faBookmark,
    faBullhorn,
    faFileLines,
    faLightbulb,
    faTag,
    IconDefinition,
} from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { match, P } from "ts-pattern"
import { ForwardedRef } from "react"
import {
    BAKED_BASE_URL,
    BAKED_GRAPHER_URL,
    EXPLORER_DYNAMIC_CONFIG_URL,
    EXPLORER_DYNAMIC_THUMBNAIL_URL,
    GRAPHER_DYNAMIC_CONFIG_URL,
    GRAPHER_DYNAMIC_THUMBNAIL_URL,
} from "../../settings/clientSettings.js"
import { EXPLORERS_ROUTE_FOLDER } from "@ourworldindata/explorer"
import {
    PreviewVariant,
    RichDataComponentVariant,
} from "./SearchChartHitRichDataTypes.js"

// Common English stop words that should be ignored in search
const STOP_WORDS = new Set([
    "the",
    "in", // matches "India"
    "is", // matches "Israel"
    "of",
    "and", // matches "Andorra"
    "a",
    "an",
    "to",
    "for",
    "with",
    "on",
    "at",
    "by",
    "from",
    "per", // matches "Peru"
    "vs",
])

export function pickEntitiesForChartHit(
    hit: IChartHit | SearchChartHit,
    selectedRegionNames: string[] | undefined
): EntityName[] {
    if (!selectedRegionNames) return []

    const availableEntities =
        hit.originalAvailableEntities ?? hit.availableEntities
    if (!availableEntities) return []

    // Build intersection of selectedRegionNames and availableEntities, so we
    // only select entities that are actually present in the chart
    const filteredEntities = R.intersection(
        selectedRegionNames,
        availableEntities
    )

    // Reverse the order so that the last picked entity is first
    const sortedEntities = [...filteredEntities].reverse()

    return sortedEntities
}

const generateGrapherTabQueryParam = ({
    tab,
    hasEntities,
}: {
    tab?: GrapherTabName | GrapherTabQueryParam
    hasEntities: boolean
}) => {
    if (tab) {
        return isValidTabQueryParam(tab)
            ? tab
            : mapGrapherTabNameToQueryParam(tab)
    }

    // If we have any entities pre-selected, we want to show the chart tab
    if (hasEntities) return GRAPHER_TAB_QUERY_PARAMS.chart

    return undefined
}

const generateGrapherTimeQueryParam = ({
    timeBounds,
    timeMode = "year",
}: {
    timeBounds: TimeBounds
    timeMode?: "year" | "day"
}) => {
    return timeBounds
        .map((time) => timeBoundToTimeBoundString(time, timeMode === "day"))
        .join("..")
}

export const getEntityQueryStr = (
    entities: EntityName[] | null | undefined
): string => {
    const hasEntities = !!entities?.length

    const countryParam = hasEntities
        ? generateSelectedEntityNamesParam(entities)
        : undefined

    const queryParams = { country: countryParam } satisfies GrapherQueryParams

    const url = Url.fromQueryParams(queryParams)

    return url.queryStr
}

export const toGrapherQueryParams = ({
    entities = [],
    tab,
    timeBounds,
    timeMode = "year",
}: {
    entities?: EntityName[]
    tab?: GrapherTabName
    timeBounds?: TimeBounds
    timeMode?: "year" | "day"
}): GrapherQueryParams => {
    const hasEntities = entities.length > 0
    return {
        tab: generateGrapherTabQueryParam({ tab, hasEntities }),
        country: hasEntities
            ? generateSelectedEntityNamesParam(entities)
            : undefined,
        time: timeBounds
            ? generateGrapherTimeQueryParam({ timeBounds, timeMode })
            : undefined,
    }
}

const generateQueryStrForChartHit = ({
    hit,
    grapherParams,
}: {
    hit: SearchChartHit
    grapherParams?: GrapherQueryParams
}): string => {
    const isExplorerView = hit.type === ChartRecordType.ExplorerView
    const isMultiDimView = hit.type === ChartRecordType.MultiDimView

    const viewQueryStr =
        isExplorerView || isMultiDimView ? hit.queryParams : undefined
    const grapherQueryStr = grapherParams
        ? queryParamsToStr(grapherParams)
        : undefined

    // Remove leading '?' from query strings
    const queryStrList = [viewQueryStr, grapherQueryStr]
        .map((queryStr) => queryStr?.replace(/^\?/, ""))
        .filter((queryStr) => queryStr)

    const queryStr = queryStrList.length > 0 ? "?" + queryStrList.join("&") : ""

    return queryStr
}

export const constructChartUrl = ({
    hit,
    grapherParams,
    overlay,
}: {
    hit: SearchChartHit
    grapherParams?: GrapherQueryParams
    overlay?: "sources" | "download-data"
}): string => {
    const viewQueryStr = generateQueryStrForChartHit({ hit, grapherParams })
    const overlayQueryStr = overlay ? `overlay=${overlay}` : ""
    const queryParts = [
        viewQueryStr?.replace(/^\?/, ""),
        overlayQueryStr,
    ].filter((queryStr) => queryStr)
    const queryStr = queryParts.length > 0 ? `?${queryParts.join("&")}` : ""

    const isExplorerView = hit.type === ChartRecordType.ExplorerView
    const basePath = isExplorerView
        ? `${BAKED_BASE_URL}/${EXPLORERS_ROUTE_FOLDER}`
        : BAKED_GRAPHER_URL

    return `${basePath}/${hit.slug}${queryStr}`
}

export const constructChartInfoUrl = ({
    hit,
    grapherParams,
}: {
    hit: SearchChartHit
    grapherParams?: GrapherQueryParams
}): string | undefined => {
    const viewQueryStr = generateQueryStrForChartHit({ hit, grapherParams })

    // Always ignore projected data to ensure that the data display shows a
    // historical data point
    const queryStr = viewQueryStr
        ? `${viewQueryStr}&ignoreProjections`
        : "?ignoreProjections"

    const isExplorerView = hit.type === ChartRecordType.ExplorerView
    const basePath = isExplorerView
        ? EXPLORER_DYNAMIC_THUMBNAIL_URL
        : GRAPHER_DYNAMIC_THUMBNAIL_URL

    return `${basePath}/${hit.slug}.values.json${queryStr}`
}

export const constructSearchResultUrl = ({
    hit,
    params,
}: {
    hit: SearchChartHit
    params: {
        version: number
        variant: RichDataComponentVariant
        entities?: EntityName[]
        numDataTableRowsPerColumn?: number
    }
}): string | undefined => {
    const viewQueryStr = generateQueryStrForChartHit({ hit })

    let optionsQueryStr = `version=${params.version}&variant=${params.variant}&ignoreProjections`
    if (params.entities && params.entities.length > 0)
        optionsQueryStr += `&entities=${generateSelectedEntityNamesParam(params.entities)}`
    if (params.numDataTableRowsPerColumn) {
        optionsQueryStr += `&numDataTableRowsPerColumn=${params.numDataTableRowsPerColumn}`
    }

    // Combine view and options query strings
    const queryParts = [
        viewQueryStr?.replace(/^\?/, ""),
        optionsQueryStr,
    ].filter((queryStr) => queryStr)
    const queryStr = queryParts.length > 0 ? `?${queryParts.join("&")}` : ""

    const isExplorerView = hit.type === ChartRecordType.ExplorerView
    const basePath = isExplorerView
        ? EXPLORER_DYNAMIC_THUMBNAIL_URL
        : GRAPHER_DYNAMIC_THUMBNAIL_URL

    return `${basePath}/${hit.slug}.search-result.json${queryStr}`
}

export const constructPreviewUrl = ({
    hit,
    grapherParams,
    variant,
    isMinimal,
    fontSize,
    imageWidth,
    imageHeight,
}: {
    hit: SearchChartHit
    grapherParams?: GrapherQueryParams
    variant: PreviewVariant
    isMinimal?: boolean
    fontSize?: number
    imageWidth?: number
    imageHeight?: number
}): string => {
    const isExplorerView = hit.type === ChartRecordType.ExplorerView

    const queryStr = generateQueryStrForChartHit({ hit, grapherParams })

    const searchParams = new URLSearchParams(
        omitUndefinedValues({
            imType: variant === "large" ? "uncaptioned" : variant,
            imMinimal: isMinimal ? "1" : "0",
            imFontSize: fontSize?.toString(),
            imWidth: imageWidth?.toString(),
            imHeight: imageHeight?.toString(),
        })
    )
    const fullQueryStr = queryStr
        ? `${queryStr}&${searchParams}`
        : `?${searchParams}`

    const basePath = isExplorerView
        ? EXPLORER_DYNAMIC_THUMBNAIL_URL
        : GRAPHER_DYNAMIC_THUMBNAIL_URL

    return `${basePath}/${hit.slug}.png${fullQueryStr}`
}

export const constructConfigUrl = ({
    hit,
}: {
    hit: SearchChartHit
}): string | undefined => {
    return match(hit)
        .with(
            { type: ChartRecordType.Chart },
            (hit) => `${GRAPHER_DYNAMIC_CONFIG_URL}/${hit.slug}.config.json`
        )
        .with(
            { type: ChartRecordType.MultiDimView },
            (hit) =>
                `${GRAPHER_DYNAMIC_CONFIG_URL}/by-uuid/${hit.chartConfigId}.config.json`
        )
        .with({ type: ChartRecordType.ExplorerView }, () => {
            const queryStr = generateQueryStrForChartHit({ hit })
            return `${EXPLORER_DYNAMIC_CONFIG_URL}/${hit.slug}.config.json${queryStr}`
        })
        .exhaustive()
}

export const CHARTS_INDEX = getIndexName(
    SearchIndexName.ExplorerViewsMdimViewsAndCharts
)
export const DATA_CATALOG_ATTRIBUTES = [
    "title",
    "slug",
    "availableEntities",
    "originalAvailableEntities",
    "variantName",
    "type",
    "queryParams",
    "availableTabs",
    "subtitle",
    "chartConfigId",
    "explorerType",
]

export function setToFacetFilters(
    facetSet: Set<string>,
    attribute: "tags" | "availableEntities"
) {
    return Array.from(facetSet).map((facet) => `${attribute}:${facet}`)
}

export function getSelectableTopics(
    tagGraph: TagGraphRoot,
    selectedTopic: string | undefined
): Set<string> {
    if (!selectedTopic)
        return new Set(tagGraph.children.map((child) => child.name))

    const area = tagGraph.children.find((child) => child.name === selectedTopic)
    if (area)
        return new Set(getAllChildrenOfArea(area).map((node) => node.name))

    return new Set()
}

export function formatCountryFacetFilters(
    countries: Set<string>,
    requireAllCountries: boolean
) {
    const facetFilters: SearchFacetFilters = []
    if (requireAllCountries) {
        // conjunction mode (A AND B): [attribute:"A", attribute:"B"]
        facetFilters.push(...setToFacetFilters(countries, "availableEntities"))
    } else {
        // disjunction mode (A OR B): [[attribute:"A", attribute:"B"]]
        facetFilters.push(setToFacetFilters(countries, "availableEntities"))
    }
    // Don't show income group-specific FMs if no countries are selected
    if (!countries.size) {
        facetFilters.push("isIncomeGroupSpecificFM:false")
    }
    return facetFilters
}

export const formatTopicFacetFilters = (
    topics: Set<string>
): SearchFacetFilters => {
    // disjunction mode (A OR B): [[attribute:"A", attribute:"B"]]
    return [setToFacetFilters(topics, "tags")]
}

export function serializeSet(set: Set<string>) {
    return set.size ? [...set].join("~") : undefined
}

export function deserializeSet(str?: string): Set<string> {
    return str ? new Set(str.split("~")) : new Set()
}

export function getFilterNamesOfType(
    filters: Filter[],
    type: FilterType
): Set<string> {
    return new Set(
        filters
            .filter((filter) => filter.type === type)
            .map((filter) => filter.name)
    )
}

export const getFilterIcon = (filter: Filter) => {
    return match(filter.type)
        .with(FilterType.COUNTRY, () => {
            // Some countries filters might be regions or historical countries,
            // for which we don't show a flag. By looking for potential region
            // names in the countries list, we're effectively filtering out
            // non-country regions.
            const countryCode = countriesByName()[filter.name]?.code
            return countryCode ? (
                <img
                    className="flag"
                    aria-hidden={true}
                    height={12}
                    width={16}
                    src={`/images/flags/${countryCode}.svg`}
                />
            ) : null
        })
        .with(FilterType.TOPIC, () => (
            <span className="icon">
                <FontAwesomeIcon icon={faTag} />
            </span>
        ))
        .with(FilterType.QUERY, () => null)
        .exhaustive()
}

export function findTopicAndRegionFilters(
    words: string[],
    allRegionsNames: string[],
    allTopics: string[],
    selectedRegionNames: Set<string>,
    selectedTopics: Set<string>,
    synonymMap: SynonymMap,
    sortOptions: { threshold: number; limit: number }
): ScoredFilter[] {
    const searchTerm = words.join(" ")

    const searchCountryTopics = (term: string) => {
        const countryFilters: ScoredFilter[] = FuzzySearch.withKey(
            allRegionsNames,
            _.identity,
            sortOptions
        )
            .searchResults(term)
            .filter(
                (result: FuzzySearchResult) =>
                    !selectedRegionNames.has(result.target)
            )
            .map((result: FuzzySearchResult) => ({
                ...createCountryFilter(result.target),
                score: result.score,
            }))

        const topicFilters: ScoredFilter[] =
            selectedTopics.size === 0
                ? FuzzySearch.withKey(allTopics, (topic) => topic, sortOptions)
                      .searchResults(term)
                      .map((result: FuzzySearchResult) => ({
                          ...createTopicFilter(result.target),
                          score: result.score,
                      }))
                : []

        return [...countryFilters, ...topicFilters]
    }

    // 1. Perform original search
    let filters = searchCountryTopics(searchTerm)

    // 2. Search with synonyms
    const synonyms = synonymMap.get(searchTerm.toLowerCase())

    if (synonyms && synonyms.length > 0) {
        // Search with each synonym and combine results
        for (const synonym of synonyms) {
            const filtersFromSynonym = searchCountryTopics(synonym)
            filters.push(...filtersFromSynonym)
        }
    }

    // For each filter type, keep only the top results then recombine into a single array
    filters = R.pipe(
        filters,
        R.groupBy((filter) => filter.type),
        R.values,
        R.flatMap((filtersOfType: ScoredFilter[]) =>
            R.pipe(
                filtersOfType,
                R.sortBy([R.prop("score"), "desc"]),
                R.uniqueBy((filter) => filter.name),
                R.take(sortOptions.limit)
            )
        )
    )

    return filters
}

/**
 * Detects words that are inside quoted phrases and should be excluded from filter matching.
 * Returns a set of word positions that should be ignored.
 */
function getQuotedWordPositions(words: string[]): Set<number> {
    const quotedPositions = new Set<number>()
    const parts = words
        .join(" ")
        .split(/("[^"]*"|\S+)/)
        .filter(Boolean)

    let wordIndex = 0
    for (const part of parts) {
        if (part.startsWith('"') && part.endsWith('"')) {
            // Count words in the quoted phrase
            const wordCount = part.split(/\s+/).filter(Boolean).length
            for (let i = 0; i < wordCount; i++) {
                quotedPositions.add(wordIndex + i)
            }
            wordIndex += wordCount
        } else if (part.trim()) {
            wordIndex++
        }
    }

    return quotedPositions
}

/**
 * Generates autocomplete suggestions for a search query and identifies any unmatched portion of the query.
 *
 * This function uses fuzzy search to match partial query words against country names and topics,
 * filtering out any countries or topics that have already been selected as filters.
 * It progressively tries to match from increasing starting points in the query until it finds matches
 * or reaches the end of the query. This prioritizes matching whole phrases from the beginning, while still
 * allowing for matching just the latter parts of the query if necessary (e.g. "air pollution" would match "Air Pollution",
 * "Indoor Air Pollution" and "Outdoor Air Pollution" and prevent the "pollution" query from being run;
 * thus not returning "Lead Pollution" as a suggestion).
 *
 * **Search Process:**
 * 1. Splitting the query into words
 * 2. Finding the earliest word index where country and/or topic matches can be found using fuzzy search
 * 3. Returning the found matches as Filter objects, sorted with exact matches first
 * 4. Also returning the unmatched portion of the query (words before the match point)
 *
 * The search utilizes the same synonym definitions as Algolia to ensure consistent experiences
 * between Algolia-powered search (homepage autocomplete, nav bar autocomplete, search results) and local fuzzy search (filter autocomplete).
 * This includes bidirectional synonyms from synonym groups (e.g., "ai" ↔ "artificial intelligence")
 * and unidirectional country alternatives (e.g., "us" → "united states"). Results from both original
 * and synonym searches are combined, with duplicates removed while preserving the highest scores.
 *
 * **Result Prioritization:**
 * Exact matches (score = 1) are prioritized in the returned suggestions array, followed by
 * the original query (as a query filter), and then partial matches sorted by score descending.
 *
 * **Examples:**
 * - Query "artificial intelligence" → also searches for synonyms like "ai", "machine learning"
 * - Query "co2 emissions" → also searches for "carbon dioxide", "c02"
 * - Query "us" → "us" gets expanded to "united states" for better country matching
 *
 * @returns Object containing suggestion filters and any unmatched query portion
 */
export function suggestFiltersFromQuerySuffix(
    query: string,
    allRegionNames: string[],
    allTopics: string[],
    filters: Filter[], // currently active filters to exclude from suggestions
    synonymMap: SynonymMap,
    sortOptions: { threshold: number; limit: number } = {
        threshold: 0.75,
        limit: 3,
    }
): {
    suggestions: Filter[]
    unmatchedQuery: string
} {
    const selectedCountryNames = getFilterNamesOfType(
        filters,
        FilterType.COUNTRY
    )
    const selectedTopics = getFilterNamesOfType(filters, FilterType.TOPIC)

    const queryWords = splitIntoWords(query)

    if (!queryWords.length || queryWords[0] === "") {
        return {
            suggestions: [],
            unmatchedQuery: "",
        }
    }

    let matchedFilters: ScoredFilter[] = []
    let matchStartIndex = queryWords.length

    for (let i = 0; i < queryWords.length; i++) {
        const wordsToSearch = queryWords.slice(i)
        const filters = findTopicAndRegionFilters(
            wordsToSearch,
            allRegionNames,
            allTopics,
            selectedCountryNames,
            selectedTopics,
            synonymMap,
            sortOptions
        )

        if (filters.length > 0) {
            matchedFilters = filters
            matchStartIndex = i
            break
        }
    }

    const unmatchedQuery = queryWords.slice(0, matchStartIndex).join(" ")

    const countryMatches = matchedFilters.filter(
        (f) =>
            // remove exact matches from country suggestions, as exact matches are
            // already handled by automatic filters (see SearchDetectedFilters).
            f.type === FilterType.COUNTRY &&
            f.score !== 1 &&
            // we matched on all regions to stop the iteration when a region is
            // found, and avoid suggesting countries contained in that region's
            // name (e.g. if "East Germany" is found, stop the iteration to
            // prevent finding "Germany"). However, we don't want to pollute the
            // autocomplete results with historical regions or aggregates, so we
            // filter them out of the suggestions.
            countriesByName()[f.name]
    )

    const topicMatches = matchedFilters.filter(
        (f) => f.type === FilterType.TOPIC
    )

    const allMatches = [...countryMatches, ...topicMatches]

    const [exactMatches, partialMatches] = R.partition(
        allMatches,
        (item) => item.score === 1
    )

    const sortedPartialMatches = partialMatches.sort(
        (a, b) => b.score - a.score
    )

    const primaryFilters = [
        exactMatches,
        ...(query ? [createQueryFilter(query)] : []),
    ]

    const combinedFilters = [
        ...(!unmatchedQuery ? primaryFilters : primaryFilters.reverse()).flat(),
        ...sortedPartialMatches,
    ]

    return {
        suggestions: combinedFilters,
        unmatchedQuery,
    }
}

/**
 * Validates whether an n-gram should be used for filter matching.
 * Filters out n-grams that:
 * - Overlap with already matched word positions
 * - Contain quoted words
 * - Start or end with stop words
 */
const isNotValidNgram = (
    ngram: Ngram,
    quotedWordPositions: Set<number>,
    matchedWordPositions: Set<number>
): boolean => {
    return (
        ngram.some(
            ({ position }) =>
                matchedWordPositions.has(position) ||
                quotedWordPositions.has(position)
        ) || hasLeadingTrailingStopWords(ngram)
    )
}

/**
 * Generator function that yields n-grams from largest to smallest.)
 */
function* generateNgrams(
    tokens: WordPositioned[],
    maxSize: number
): Generator<Ngram> {
    for (let n = maxSize; n >= 1; n--) {
        for (let i = 0; i <= tokens.length - n; i++) {
            yield tokens.slice(i, i + n)
        }
    }
}

/**
 * Gets filter suggestions using contiguous sequence of words (n-grams) for
 * multi-entity matching in a given query.
 *
 * Where `suggestFiltersFromQuerySuffix` progressively tries to match
 * from increasing starting points in the query and returns a list of possible
 * matches for that position (1 position, multiple matches), this function tries
 * to find the best match for all possible contiguous sequences of words
 * (n-grams) in the query (multiple positions, 1 match per position).
 *
 * Generates n-grams from 1 to 4 non stop-words, non quotes phrases,
 * prioritizing longer phrases over shorter ones. Uses overlap detection to
 * prevent the same word positions from being matched multiple times. Keeps a
 * single country or filter topic per n-gram then deduplicates overall results
 * by name.
 *
 * @returns Array of deduplicated scored filters
 */
export function extractFiltersFromQuery(
    query: string,
    allRegionNames: string[],
    allTopics: string[],
    filters: Filter[], // currently active filters to exclude from suggestions
    sortOptions: { threshold: number; limit: number },
    synonymMap: SynonymMap
): ScoredFilterPositioned[] {
    if (!query) return []

    const selectedCountryNames = getFilterNamesOfType(
        filters,
        FilterType.COUNTRY
    )
    const selectedTopics = getFilterNamesOfType(filters, FilterType.TOPIC)

    const allFilters: ScoredFilterPositioned[] = []
    const matchedWordPositions = new Set<number>()

    const words = splitIntoWords(query)

    // Get positions of words inside quoted phrases
    const quotedWordPositions = getQuotedWordPositions(words)

    const tokens: WordPositioned[] = words.map((word, index) => ({
        word,
        position: index,
    }))

    const maxNgramSize = Math.min(tokens.length, 4) // Topics and countries mostly fit within 4 words

    // Generate and process n-grams on-the-fly, prioritizing longer phrases
    for (const ngram of generateNgrams(tokens, maxNgramSize)) {
        if (isNotValidNgram(ngram, quotedWordPositions, matchedWordPositions)) {
            continue
        }

        const ngramWords = R.map(ngram, R.prop("word"))
        const ngramPositions = R.map(ngram, R.prop("position"))

        const filtersFromNgram = findTopicAndRegionFilters(
            ngramWords,
            allRegionNames,
            allTopics,
            selectedCountryNames,
            selectedTopics,
            synonymMap,
            sortOptions
        )

        const bestFilter = _.maxBy(filtersFromNgram, (f) => f.score)
        if (bestFilter) {
            // Store the original positions for later use in replacement logic
            allFilters.push({
                ...bestFilter,
                positions: ngramPositions,
            })
            // Mark these word positions as matched to prevent overlaps
            ngramPositions.forEach((pos: number) =>
                matchedWordPositions.add(pos)
            )
        }
    }

    return R.uniqueBy(allFilters, (filter) => filter.name)
}

export function createFilter(type: FilterType) {
    return (name: string): Filter => ({ type, name })
}

export const createCountryFilter = createFilter(FilterType.COUNTRY)
export const createTopicFilter = createFilter(FilterType.TOPIC)
export const createQueryFilter = createFilter(FilterType.QUERY)

/**
 * Returns a click handler that focuses an input element when clicking on the
 * target element or its children. If checkTargetEquality is true, only focus
 * the input if the click happened on the element where the handler is
 * attached (effectively not registering clicks on children).
 */
export const createFocusInputOnClickHandler = (
    inputRef: ForwardedRef<HTMLInputElement>,

    checkTargetEquality: boolean = false
) => {
    const handleClick = (e: React.MouseEvent) => {
        if (
            (!checkTargetEquality || e.target === e.currentTarget) &&
            isCurrentRef(inputRef)
        ) {
            inputRef.current.focus()
        }
    }

    return handleClick
}

/*
 * Type guard to check if a ref is a RefObject with a non-null current property
 */
export function isCurrentRef(
    inputRef: ForwardedRef<HTMLInputElement>
): inputRef is React.RefObject<HTMLInputElement> {
    return (
        inputRef !== null &&
        typeof inputRef === "object" &&
        "current" in inputRef &&
        inputRef.current !== null
    )
}

export const getSearchAutocompleteId = () => "search-autocomplete-listbox"

export const getSearchAutocompleteItemId = (index: number) =>
    index >= 0 ? `search-autocomplete-item-${index}` : undefined

export const getFilterAriaLabel = (
    filter: Filter,
    action: "add" | "remove"
) => {
    const actionName = action === "add" ? "Add" : "Remove"
    return match(filter.type)
        .with(FilterType.QUERY, () => `Search for ${filter.name}`)
        .with(
            P.union(FilterType.COUNTRY, FilterType.TOPIC),
            () => `${actionName} ${filter.name} ${filter.type} filter`
        )
        .exhaustive()
}

export const isValidResultType = (
    value: string | undefined
): value is SearchResultType => {
    return Object.values(SearchResultType).includes(value as SearchResultType)
}

export const getSelectedTopic = (filters: Filter[]): string | undefined => {
    const selectedTopics = getFilterNamesOfType(filters, FilterType.TOPIC)
    return selectedTopics.size > 0 ? [...selectedTopics][0] : undefined
}

export function getSelectedTopicType(
    filters: Filter[],
    areaNames: string[]
): SearchTopicType | null {
    const selectedTopic = getSelectedTopic(filters)
    if (!selectedTopic) return null

    return areaNames.includes(selectedTopic)
        ? SearchTopicType.Area
        : SearchTopicType.Topic
}

/**
 * Checks if the search is in browsing mode, which is defined as having no query
 * and no filters applied.
 */
export const isBrowsing = (filters: Filter[], query: string) => {
    return query.trim() === "" && filters.length === 0
}

/**
 * Computes the effective result type that should be displayed/used in the UI.
 * This respects constraints (e.g., "all" is not allowed when browsing) while
 * preserving the user's desired result type in the state.
 */
export const getEffectiveResultType = (
    filters: Filter[],
    query: string,
    desiredResultType: SearchResultType
): SearchResultType => {
    return isBrowsing(filters, query) &&
        desiredResultType === SearchResultType.ALL
        ? SearchResultType.DATA
        : desiredResultType
}

export const getUrlParamNameForFilter = (filter: Filter) =>
    match(filter.type)
        .with(FilterType.COUNTRY, () => SearchUrlParam.COUNTRY)
        .with(FilterType.TOPIC, () => SearchUrlParam.TOPIC)
        .with(FilterType.QUERY, () => SearchUrlParam.QUERY)
        .exhaustive()

/**
 * Builds a fully qualified search URL for the provided autocomplete filter.
 *
 * - add  a `resultType=ALL` parameter to broaden search results beyond the
 *   default data-only view
 * - `COUNTRY` filters include unmatched query terms
 *
 * Examples:
 * - Country filter "Kenya" with unmatched query "emissions":
 *   "?country=Kenya&q=emissions&resultType=all"
 * - Topic filter "Health" (unmatched query discarded, if any):
 *   "?topic=Health&resultType=all"
 * - Query filter "outdoor": "?q=outdoor&resultType=all"
 *
 * See also `SearchAutocomplete.tsx` for similar logic in the search page.
 */
export const getItemUrlForFilter = (
    filter: Filter,
    unmatchedQuery: string
): string => {
    const filterParam = {
        [getUrlParamNameForFilter(filter)]: filter.name,
        [SearchUrlParam.RESULT_TYPE]: SearchResultType.ALL,
    }

    const queryParams = match(filter.type)
        .with(FilterType.COUNTRY, () => ({
            ...filterParam,
            ...(unmatchedQuery && {
                [SearchUrlParam.QUERY]: unmatchedQuery,
            }),
        }))
        .with(FilterType.QUERY, FilterType.TOPIC, () => filterParam)
        .exhaustive()

    return `${BAKED_BASE_URL}${SEARCH_BASE_PATH}${queryParamsToStr(queryParams)}`
}

export function getPageTypeNameAndIcon(pageType: OwidGdocType): {
    name: string
    icon: IconDefinition
} {
    return match(pageType)
        .with(OwidGdocType.AboutPage, () => ({
            name: "About",
            icon: faFileLines,
        }))
        .with(OwidGdocType.Article, () => ({ name: "Article", icon: faBook }))
        .with(OwidGdocType.DataInsight, () => ({
            name: "Data Insight",
            icon: faLightbulb,
        }))
        .with(OwidGdocType.LinearTopicPage, OwidGdocType.TopicPage, () => ({
            name: "Topic page",
            icon: faBookmark,
        }))
        .with(OwidGdocType.Announcement, () => ({
            name: "Announcement",
            icon: faBullhorn,
        }))
        .with(
            OwidGdocType.Author, // Should never be indexed
            OwidGdocType.Fragment, // Should never be indexed
            OwidGdocType.Homepage, // Should never be indexed
            () => ({ name: "", icon: faFileLines })
        )
        .exhaustive()
}
export const SEARCH_BASE_PATH = "/search"

export const getPaginationOffsetAndLength = (
    pageParam: number,
    firstPageSize: number,
    laterPageSize: number
) => {
    const offset =
        pageParam === 0 ? 0 : firstPageSize + (pageParam - 1) * laterPageSize
    const length = pageParam === 0 ? firstPageSize : laterPageSize
    return { offset, length }
}

export const getNbPaginatedItemsRequested = (
    currentPageIndex: number,
    firstPageSize: number,
    laterPageSize: number,
    lastPageHits: number
) => {
    return currentPageIndex === 0
        ? firstPageSize
        : firstPageSize + (currentPageIndex - 1) * laterPageSize + lastPageHits
}

/**
 * Helper function to remove matched words and preceding stop words from query
 * when a filter is selected.
 */
export function removeMatchedWordsWithStopWords(
    originalWords: string[],
    matchedPositions: number[]
): string {
    if (!matchedPositions.length) return originalWords.join(" ")

    const wordsToRemove = new Set(matchedPositions)

    // For each matched position, remove any consecutive stop words that immediately precede it
    for (const matchedPos of matchedPositions) {
        // Look backwards from this matched position to remove consecutive preceding stop words
        for (let i = matchedPos - 1; i >= 0; i--) {
            const word = originalWords[i].toLowerCase()
            if (STOP_WORDS.has(word)) {
                wordsToRemove.add(i)
            } else {
                // Stop when we hit a non-stop word
                break
            }
        }
    }

    return originalWords
        .filter((_, index) => !wordsToRemove.has(index))
        .join(" ")
}

export const splitIntoWords = (text: string) => text.trim().split(/\s+/)

export const isNotStopWord = (word: string) =>
    !STOP_WORDS.has(word.toLowerCase())

const hasLeadingTrailingStopWords = (ngram: Ngram) => {
    if (ngram.length === 0) return false
    const firstWord = ngram[0].word.toLowerCase()
    const lastWord = ngram[ngram.length - 1].word.toLowerCase()
    return STOP_WORDS.has(firstWord) || STOP_WORDS.has(lastWord)
}
